PythonのORMライブラリsqlalchemyで利用可能なコネクションプーリングの実装について調べてみた
CX事業本部@大阪の岩田です。Python向けのORMライブラリsqlalchemyは標準でコネクションプーリングの実装が組み込まれており、create_engine()
を呼出す際の名前付き引数poolclass
の指定によってコネクションプーリングの実装を切り替えることができます。先日コネクションプーリングの実装について調べる機会があったので、内容をご紹介します。
環境
今回利用した環境です。
- OS X 10.14.6
- Python 3.8.2
- sqlalchemy 1.3.16
- pymysql 0.9.3
利用できるコネクションプーリングの実装
sqlalchemyは標準で以下のコネクションプーリングの実装を提供しています。
- QueuePool
- NullPool
- SingletonThreadPool
- StaticPool
- AssertionPool
例としてNullPool
を使う場合は以下のようにpoolclass
を指定してcreate_engine
を呼び出します。
engine = sqlalchemy.create_engine('mysql+pymysql://root:mysql@localhost/mysql?charset=utf8', poolclass=NullPool)
各実装について詳細を見ていきましょう。
QueuePool
オープン状態のDB接続数を制限するコネクションプーリングの実装で、パラメータpool_size
とmax_overflow
を指定することでプールするDB接続数最大の同時接続数を制御することが可能です。各パラメータの意味は以下の通りです。
- pool_size
- sqlalchemyがプールする接続数を指定するパラメータです。デフォルトでは5つの接続を永続化してプールします。
pool_size
を0に設定すると無制限に接続をプールするようになります。
- sqlalchemyがプールする接続数を指定するパラメータです。デフォルトでは5つの接続を永続化してプールします。
- max_overflow
pool_size
で設定した上限に加えて、追加でOPEN可能な接続数を指定するパラメータです。sqlarchemyを利用するアプリケーション側からはpool_size
+max_overflow
の分だけDBとの接続をOPENすることが可能になります。デフォルト値は10で-1に指定することで無制限になります。
create_engine
の呼び出し時にpoolclass
の指定がない場合はQueuePoolがデフォルトで利用されます。実際にQueuePoolの挙動を確認してみましょう。Pythonのインタプリタから以下のように入力し、MySQLに接続してみましょう。
>>> import sqlalchemy >>> from sqlalchemy.pool import QueuePool >>> >>> engine = sqlalchemy.create_engine(f'mysql+pymysql://root:mysql@localhost/mysql?charset=utf8', poolclass=QueuePool) >>> con1 = engine.connect()
この状態でinformation_schema.processlist
から接続状況を確認すると以下のようになります。
mysql> select * from information_schema.processlist where db = 'mysql'; +-----+------+------------------+-------+---------+------+-------+------+ | ID | USER | HOST | DB | COMMAND | TIME | STATE | INFO | +-----+------+------------------+-------+---------+------+-------+------+ | 105 | root | 172.18.0.1:47704 | mysql | Sleep | 33 | | NULL | +-----+------+------------------+-------+---------+------+-------+------+ 1 row in set (0.00 sec)
OPENしている接続は1つということが分かります。続けて先程OPENした接続con1
をCLOSEしてみます。
>>> con1.close()
再度information_schema.processlist
に問い合わせてみます。
mysql> select * from information_schema.processlist where db = 'mysql'; +-----+------+------------------+-------+---------+------+-------+------+ | ID | USER | HOST | DB | COMMAND | TIME | STATE | INFO | +-----+------+------------------+-------+---------+------+-------+------+ | 105 | root | 172.18.0.1:47704 | mysql | Sleep | 286 | | NULL | +-----+------+------------------+-------+---------+------+-------+------+ 1 row in set (0.00 sec)
con1をcloseしたにも関わらず、DB接続が残り続けていることが分かります。QueuePoolはpool_size
で指定された数のDB接続を永続化してプールするため、このような振る舞いとなります。
今度は試しに接続を6つOPENしてみましょう。
>>> con1 = engine.connect() >>> con2 = engine.connect() >>> con3 = engine.connect() >>> con4 = engine.connect() >>> con5 = engine.connect() >>> con6 = engine.connect()
接続数を確認します。
mysql> select * from information_schema.processlist where db = 'mysql'; +-----+------+------------------+-------+---------+------+-------+------+ | ID | USER | HOST | DB | COMMAND | TIME | STATE | INFO | +-----+------+------------------+-------+---------+------+-------+------+ | 125 | root | 172.18.0.1:47716 | mysql | Sleep | 6 | | NULL | | 105 | root | 172.18.0.1:47704 | mysql | Sleep | 520 | | NULL | | 124 | root | 172.18.0.1:47712 | mysql | Sleep | 8 | | NULL | | 126 | root | 172.18.0.1:47720 | mysql | Sleep | 4 | | NULL | | 127 | root | 172.18.0.1:47724 | mysql | Sleep | 2 | | NULL | | 123 | root | 172.18.0.1:47708 | mysql | Sleep | 10 | | NULL | +-----+------+------------------+-------+---------+------+-------+------+ 6 rows in set (0.00 sec)
接続数は6まで増えました。con1 ~ con6を全てcloseしてみます。
>>> con1.close() >>> con2.close() >>> con3.close() >>> con4.close() >>> con5.close() >>> con6.close()
接続数を確認します。
mysql> select * from information_schema.processlist where db = 'mysql'; +-----+------+------------------+-------+---------+------+-------+------+ | ID | USER | HOST | DB | COMMAND | TIME | STATE | INFO | +-----+------+------------------+-------+---------+------+-------+------+ | 125 | root | 172.18.0.1:47716 | mysql | Sleep | 5 | | NULL | | 105 | root | 172.18.0.1:47704 | mysql | Sleep | 12 | | NULL | | 124 | root | 172.18.0.1:47712 | mysql | Sleep | 7 | | NULL | | 126 | root | 172.18.0.1:47720 | mysql | Sleep | 3 | | NULL | | 123 | root | 172.18.0.1:47708 | mysql | Sleep | 9 | | NULL | +-----+------+------------------+-------+---------+------+-------+------+ 5 rows in set (0.00 sec)
con1 ~ con6をcloseした結果、接続数は5に減りました。これはQueuePoolのパラメータpool_size
がデフォルトで5に設定されているためです。
今度はcon1 ~ con15までOPENしてみましょう。max_overflow
のデフォルト値は10なので、pool_size
の5と合わせて15接続まではOPEN可能な想定です。
>>> con1 = engine.connect() >>> con2 = engine.connect() >>> con3 = engine.connect() >>> con4 = engine.connect() >>> con5 = engine.connect() >>> con6 = engine.connect() >>> con7 = engine.connect() >>> con8 = engine.connect() >>> con9 = engine.connect() >>> con10 = engine.connect() >>> con11 = engine.connect() >>> con12 = engine.connect() >>> con13 = engine.connect() >>> con14 = engine.connect() >>> con15 = engine.connect()
接続状況を確認してみます。
mysql> select * from information_schema.processlist where db = 'mysql'; +-----+------+------------------+-------+---------+------+-------+------+ | ID | USER | HOST | DB | COMMAND | TIME | STATE | INFO | +-----+------+------------------+-------+---------+------+-------+------+ | 148 | root | 172.18.0.1:47756 | mysql | Sleep | 7 | | NULL | | 125 | root | 172.18.0.1:47716 | mysql | Sleep | 316 | | NULL | | 141 | root | 172.18.0.1:47728 | mysql | Sleep | 7 | | NULL | | 149 | root | 172.18.0.1:47760 | mysql | Sleep | 6 | | NULL | | 144 | root | 172.18.0.1:47740 | mysql | Sleep | 7 | | NULL | | 151 | root | 172.18.0.1:47764 | mysql | Sleep | 2 | | NULL | | 105 | root | 172.18.0.1:47704 | mysql | Sleep | 323 | | NULL | | 145 | root | 172.18.0.1:47744 | mysql | Sleep | 7 | | NULL | | 124 | root | 172.18.0.1:47712 | mysql | Sleep | 318 | | NULL | | 126 | root | 172.18.0.1:47720 | mysql | Sleep | 314 | | NULL | | 143 | root | 172.18.0.1:47736 | mysql | Sleep | 7 | | NULL | | 142 | root | 172.18.0.1:47732 | mysql | Sleep | 7 | | NULL | | 123 | root | 172.18.0.1:47708 | mysql | Sleep | 320 | | NULL | | 146 | root | 172.18.0.1:47748 | mysql | Sleep | 7 | | NULL | | 147 | root | 172.18.0.1:47752 | mysql | Sleep | 7 | | NULL | +-----+------+------------------+-------+---------+------+-------+------+ 15 rows in set (0.00 sec)
15接続確立されていることが分かります。16個目の接続OPENを試してみます。
>>> con16 = engine.connect() Traceback (most recent call last): File "<stdin>", line 1, in <module> File "/Users/xxx/.venv/lib/python3.8/site-packages/sqlalchemy/engine/base.py", line 2218, in connect return self._connection_cls(self, **kwargs) File "/Users/xxx/.venv/lib/python3.8/site-packages/sqlalchemy/engine/base.py", line 103, in __init__ else engine.raw_connection() File "/Users/xxx/.venv/lib/python3.8/site-packages/sqlalchemy/engine/base.py", line 2317, in raw_connection return self._wrap_pool_connect( File "/Users/xxx/.venv/lib/python3.8/site-packages/sqlalchemy/engine/base.py", line 2285, in _wrap_pool_connect return fn() File "/Users/xxx/.venv/lib/python3.8/site-packages/sqlalchemy/pool/base.py", line 303, in unique_connection return _ConnectionFairy._checkout(self) File "/Users/xxx/.venv/lib/python3.8/site-packages/sqlalchemy/pool/base.py", line 773, in _checkout fairy = _ConnectionRecord.checkout(pool) File "/Users/xxx/.venv/lib/python3.8/site-packages/sqlalchemy/pool/base.py", line 492, in checkout rec = pool._do_get() File "/Users/xxx/.venv/lib/python3.8/site-packages/sqlalchemy/pool/impl.py", line 127, in _do_get raise exc.TimeoutError( sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached, connection timed out, timeout 30 (Background on this error at: http://sqlalche.me/e/3o7r)
タイムアウトしてsqlalchemy.exc.TimeoutError
raiseされました。想定通りの動作ですね。
NullPool
続いてNullPoolです。この実装はコネクションプーリングを行いません。挙動を確認してみましょう。
まずNullPoolを指定して接続をOPENします。
>>> import sqlalchemy >>> from sqlalchemy.pool import NullPool >>> engine = sqlalchemy.create_engine(f'mysql+pymysql://root:mysql@localhost/mysql?charset=utf8', poolclass=NullPool) >>> con1 = engine.connect()
接続状況は以下の通りになります。
mysql> select * from information_schema.processlist where db = 'mysql'; +-----+------+------------------+-------+---------+------+-------+------+ | ID | USER | HOST | DB | COMMAND | TIME | STATE | INFO | +-----+------+------------------+-------+---------+------+-------+------+ | 182 | root | 172.18.0.1:47768 | mysql | Sleep | 3 | | NULL | +-----+------+------------------+-------+---------+------+-------+------+ 1 row in set (0.00 sec)
con1をcloseしてみます。
>>> con1.close()
NullPoolではsqlalchemy側でコネクションをプーリングしないため、実際に接続がCLOSEされているはずです。 確認してみましょう。
mysql> select * from information_schema.processlist where db = 'mysql'; Empty set (0.00 sec)
結果が0レコードに変わっており、sqlalchemyがコネクションをプーリングしていないことが分かります。
SingletonThreadPool
スレッド毎に1つのDB接続を管理するコネクションプーリングの実装です。SQLiteのインメモリーデータベースを使用したテストでの利用のみ想定しており、本番環境での利用は推奨されません。
本番環境での利用は推奨されないということですが、一応動作を確認しておきましょう。
まずスレッドを1つ生成してスレッド内でengine.connect
を3回呼び出してみます。
>>> import threading >>> import sqlalchemy >>> from sqlalchemy.pool import SingletonThreadPool >>> >>> def worker(): ... engine = sqlalchemy.create_engine(f'mysql+pymysql://root:mysql@localhost/mysql?charset=utf8', poolclass=SingletonThreadPool) ... con1 = engine.connect() ... con2 = engine.connect() ... con3 = engine.connect() ... >>> t1 = threading.Thread(target=worker) >>> t1.start()
MySQLから確認した接続状況です。
mysql> select * from information_schema.processlist where db = 'mysql'; +-----+------+------------------+-------+---------+------+-------+------+ | ID | USER | HOST | DB | COMMAND | TIME | STATE | INFO | +-----+------+------------------+-------+---------+------+-------+------+ | 285 | root | 172.18.0.1:47816 | mysql | Sleep | 71 | | NULL | +-----+------+------------------+-------+---------+------+-------+------+ 1 row in set (0.00 sec)
con1 ~ con3はプーリングされた同一のDB接続を利用しているため、1レコードしかヒットしません。
もう1つスレッドを作成してみましょう。
>>> t2 = threading.Thread(target=worker) >>> t2.start()
再度MySQLから確認します。
mysql> select * from information_schema.processlist where db = 'mysql' order by id; +-----+------+------------------+-------+---------+------+-------+------+ | ID | USER | HOST | DB | COMMAND | TIME | STATE | INFO | +-----+------+------------------+-------+---------+------+-------+------+ | 285 | root | 172.18.0.1:47816 | mysql | Sleep | 151 | | NULL | | 290 | root | 172.18.0.1:47820 | mysql | Sleep | 7 | | NULL | +-----+------+------------------+-------+---------+------+-------+------+ 2 rows in set (0.00 sec)
2つ目のスレッド用に新しくDB接続が確立していることが分かります。
StaticPool
全てのリクエストに対して単一のDB接続を利用するコネクションプーリングの実装です。DBとの接続をシングルトンとして利用したい場合に利用すると良いでしょう。
こちらも実際にPythonのインタプリタから動作を確認してみます。
>>> import sqlalchemy >>> from sqlalchemy.pool import StaticPool >>> engine = sqlalchemy.create_engine(f'mysql+pymysql://root:mysql@localhost/mysql?charset=utf8', poolclass=StaticPool) >>> con1 = engine.connect()
接続状況は以下の通りです。
mysql> select * from information_schema.processlist where db = 'mysql' order by id; +-----+------+------------------+-------+---------+------+-------+------+ | ID | USER | HOST | DB | COMMAND | TIME | STATE | INFO | +-----+------+------------------+-------+---------+------+-------+------+ | 212 | root | 172.18.0.1:47784 | mysql | Sleep | 3 | | NULL | +-----+------+------------------+-------+---------+------+-------+------+ 1 row in set (0.00 sec)
追加でcon2をOPENしてみましょう。con1とcon2共に同一のDB接続を参照するはずです。
>>> con2 = engine.connect()
MySQLから確認してみます。
mysql> select * from information_schema.processlist where db = 'mysql' order by id; +-----+------+------------------+-------+---------+------+-------+------+ | ID | USER | HOST | DB | COMMAND | TIME | STATE | INFO | +-----+------+------------------+-------+---------+------+-------+------+ | 212 | root | 172.18.0.1:47784 | mysql | Sleep | 19 | | NULL | +-----+------+------------------+-------+---------+------+-------+------+ 1 row in set (0.00 sec)
con2を追加でOPENしましたが、結果は特に変わっていません。con1,con2共に同一のDB接続を利用していることが分かります。
続いてcon1,con2共にcloseしてみます。
>>> con1.close() >>> con2.close()
再度MySQLから確認します。
mysql> select * from information_schema.processlist where db = 'mysql' order by id; +-----+------+------------------+-------+---------+------+-------+------+ | ID | USER | HOST | DB | COMMAND | TIME | STATE | INFO | +-----+------+------------------+-------+---------+------+-------+------+ | 212 | root | 172.18.0.1:47784 | mysql | Sleep | 2 | | NULL | +-----+------+------------------+-------+---------+------+-------+------+ 1 row in set (0.00 sec)
con1,con2共にcloseしましたが、sqlalchemyが接続をプーリングしているため、実際にはMySQLとの接続は切断されていないことが分かります。
AssertionPool
接続を1つだけプールし、プールに対するDB接続の利用要求を1つだけ許可する実装です。意図せず多数のDB接続確立されているような状況下でのデバッグ用途での利用を目的としており、同時に複数のDB接続利用要求が発生すると、AssertionPoolは例外をraiseします。
こちらもPythonのインタプリタから試してみましょう。
>>> import sqlalchemy >>> from sqlalchemy.pool import AssertionPool >>> engine = sqlalchemy.create_engine(f'mysql+pymysql://root:mysql@localhost/mysql?charset=utf8', poolclass=AssertionPool) con1 = engine.connect() >>> con1 = engine.connect()
MySQLから接続状況を確認してみます。
mysql> select * from information_schema.processlist where db = 'mysql'; +-----+------+------------------+-------+---------+------+-------+------+ | ID | USER | HOST | DB | COMMAND | TIME | STATE | INFO | +-----+------+------------------+-------+---------+------+-------+------+ | 235 | root | 172.18.0.1:47796 | mysql | Sleep | 24 | | NULL | +-----+------+------------------+-------+---------+------+-------+------+ 1 row in set (0.00 sec)
接続が1つOPENされました。
con1からDB接続を参照している状態で、新たにcon2を要求してみます。
>>> con2 = engine.connect() Traceback (most recent call last): File "<stdin>", line 1, in <module> File "/Users/xxx/.venv/lib/python3.8/site-packages/sqlalchemy/engine/base.py", line 2218, in connect return self._connection_cls(self, **kwargs) File "/Users/xxx/.venv/lib/python3.8/site-packages/sqlalchemy/engine/base.py", line 103, in __init__ else engine.raw_connection() File "/Users/xxx/.venv/lib/python3.8/site-packages/sqlalchemy/engine/base.py", line 2317, in raw_connection return self._wrap_pool_connect( File "/Users/xxx/.venv/lib/python3.8/site-packages/sqlalchemy/engine/base.py", line 2285, in _wrap_pool_connect return fn() File "/Users/xxx/.venv/lib/python3.8/site-packages/sqlalchemy/pool/base.py", line 303, in unique_connection return _ConnectionFairy._checkout(self) File "/Users/xxx/.venv/lib/python3.8/site-packages/sqlalchemy/pool/base.py", line 773, in _checkout fairy = _ConnectionRecord.checkout(pool) File "/Users/xxx/.venv/lib/python3.8/site-packages/sqlalchemy/pool/base.py", line 492, in checkout rec = pool._do_get() File "/Users/xxx/.venv/lib/python3.8/site-packages/sqlalchemy/pool/impl.py", line 471, in _do_get raise AssertionError("connection is already checked out" + suffix) AssertionError: connection is already checked out at: File "<stdin>", line 1, in <module>
AssertionErrorがraiseされました。
続いてcon1をcloseしてみます。
>>> con1.close()
MySQLから接続状況を確認してみます。
mysql> select * from information_schema.processlist where db = 'mysql'; +-----+------+------------------+-------+---------+------+-------+------+ | ID | USER | HOST | DB | COMMAND | TIME | STATE | INFO | +-----+------+------------------+-------+---------+------+-------+------+ | 235 | root | 172.18.0.1:47796 | mysql | Sleep | 17 | | NULL | +-----+------+------------------+-------+---------+------+-------+------+ 1 row in set (0.00 sec)
MySQLとの接続自体は残り続けており、sqlalchemyが接続をプーリングしていることが分かります。
まとめ
sqlalchemyのコネクションプーリング機構について調査してみました。元々sqlalchemyがデフォルトでコネクションプーリングの機構を備えていることを知らず、NullPool相当の挙動を期待して実装を行っていたのですが、意図通りに接続がCLOSEされないことから詳細を行うことになりました。やはり未経験のライブラリのを利用する際は、しっかりドキュメントを読み込むことが重要だなと反省しました。この記事が誰かの参考になれば幸いです。